序列化探索 - Jackson
写这篇文章时,我一度陷入了纠结与不安,再次体会到了聚焦的重要性。Jackson看似简单,实则功能强大,这两天有些迷失,不知道要看些什么,要写些什么。但路得一步一步走,饭得一口一口吃,纵使它可供探索的点繁如星辰,我也得将焦点拉回,否则就叫失控。至于其它的点,以后再说。因此,本文将聚焦如下几点
- Jackson的能力
- 基本原理
- module工作原理
基本组成
Jackson文档怎么看,是一个问题。如果初次接触Jackson,看主项目的介绍半个小时,多半还是云里雾里,我认为这是Jackson文档做的很不好的一点,并没有一个Guide,而是需要自己一个一个项目看,而如果你恰巧点到了第三方module,就更不知道它在说什么了。
总的说来,Jackson由三部分组成
- Jackson-core:提供低层级的流式API,灵活程度高,效率高,但易用性差
- Jackson-annotations:提供注解定义,这些注解是与我么打交道最多的
- Jackson-databind:在前两个部分的基础上封装了一层,提供序列化和反序列化功能,我们直接使用的API,基本都来自于此。
在jackson-databind中,提供了可插入的module机制,允许第三方定义自己的类型转换库,通过ObjectMapper().registerModule的方式注册,常见的有
- 针对java.time包下的时间的类型库
- 针对kotlin的类型库
- 好多其它的
又,Jackson并非仅仅针对Json,还支持Protobuf、TOML、Yaml等诸多格式,他们是通过自定义jackson-core提供的流式API和Codec实现的。
还有,Jackson中的很多概念都和kotlinx.serialization类似,学习过程中可做类比,加深印象。
能力
Jackson提供三种方式进行序列化和反序列化
- 低层级的流式API,直接控制基础token的写入(要理解Jackson所谓的token,一个字符、一个起始符,都是token)
- ObjectMapper,直接进行POJO和json之间的转换,也可以进行POJO和POJO之间的转换,但原理是一样的。
- 基于TreeModel,脱离POJO直接构建json结构
流式API
要想快,就用流式API,只需引入jackson-core,这里简单展示
1 | class ResourceWithStream { |
输出
1 | {"resourceId":"1","resourceType":"record","usn":123456} |
注解
Jackson的注解可就多了去了,一个简单而骚气的展示:序列化时将Map类型的数据提取到顶层,反序列化时再将这些数据塞回去。
1 | class Resource2 { |
考虑到注解多又常用,这里有不能每一个都展示,我们穷举场景
如果需要自定义POJO某个属性的和序列化结果的对应关系,使用@JsonProperty、@JsonSetter、@JsonGetter
如果需要指定序列化后字段之间的顺序,使用@JsonPropertyOrder
如果POJO的某个字段需要直接当做json格式输出,使用@JsonRawValue
如果需要忽略某些字段,使用@JsonIgnore、@JsonIgnoreProperties、@JsonIgnoreType
如果需要修改Jackson针对属性、POJO创建器等的检测逻辑,使用@JsonAutoDetect配置
典型地就是设置field、getter、setter、creator方法的可见性,指定哪种可见性能够被检测到
如果需要根据属性的值的情况决定是否序列化,可以使用@JsonInclude,它甚至可以指定当属性为某个特定的值时才序列化
如果某个类需要将某个属性当做该类型最终序列化结果的值,使用@JsonValue,一般用于枚举
如果需要配置jata.util中的日期相关序列化和反序列化转换逻辑,使用@JsonFormat
如果同一个POJO,在不同的情况下需要有不同的序列化结果,使用@JsonView
如果POJO中存在自定义类型,要想将该类型字段在序列化时提取到顶层,使用@JsonUnwrapped
如果POJO中存在map,想要把map的值提到顶层,使用@JsonAnyGetter
反之,如果json拥有多个属性,POJO中仅有少量字段,为了将多余的字段直接放在map中,使用@JsonAnySetter
如果想要自定义反序列化时调用的POJO构建器,使用@JsonCreator
针对枚举类型,如果想要在反序列化时匹配不到的情况下设置一个默认值,使用@JsonEnumDefaultValue
如果在反序列化时需要强制干预一个字段的值,可以使用@JacksonInject,但要结合ObjectMapper..setInjectableValues使用
如果要使用多态,需要使用@JsonSubTypes、@JsonTypeId、@JsonTypeInfo、@JsonTypeName
如果你的POJO之间有相互引用,导致序列化时出现递归,你需要使用@JsonManagedReference、@JsonBackReference或@JsonIdentityInfo解递归
如果希望某个POJO序列化后放在一个字段下,使用@JsonRootName
他们的使用,可以参考这篇文章,还挺全的:就是我
TreeModel
树形结构,是Jackson提供的又一强大功能,它允许我们直接构建Json,类似Kotlin的JsonElement,但是更强大。给一个简单的例子
1 | fun main() { |
输出
1 | { |
可扩展性
在MapperFeature、SerializationFeature、DeserializationFeature中,定义了很多开关,可根据需要打开或关闭。具体看三个文档
工作原理
流式API
流式API也支持很多功能,翻开JsonGenerator源码,将近三千行的文件长度有点吓人,但总的来说,它大概也只有功能配置和各种写方法。这其中,我们目前只关心之前用到的内容:
- 一个基本的写方法的实现,即writeXXX
- 序列化过程中的状态维护,即JsonWriteContext
- ObjectMapper的基础,即ObjectCodec
最底层的原理
一般来说,通过JsonFactoryBuilder().build().createGenerator(xxx)
创建出来的是UTC8JsonGenerator
实例,我们来看两个方法的示例
1 | // 写对象开头 |
我们忽略_cfgPrettyPrinter这个东西,它不是关键。有要点
- 流式API的输出原理:维护一个输出流,维护一个缓存字节数组。每次写入,只是写入缓存数组中,只有主动调用flush()或缓存长度超过设定值(_outputEnd)时,才会将数据批量写入输入流,这样可以提升效率
- 每次写入前,都会调用_verifyValueWrite对当前的状态进行验证,它验证的内容其实是即将写入的内容是否符合Json格式,这是通过JsonStreamContext实现的(对写,对应JsonWriteContext),它维护了序列化过程中,当前的写入状态。比如,刚刚写入了一个key,现在必须写入一个value,否则就会报错。
- 在写对象的起始符时,也创建了jsonContext的子context,可见,Context是有层次结构的,其结构和Json的层次结构保持一致。
看看JsonContext的层次结构,我们大概能猜出其工作原理
三个type(TYPE_ROOT等),是Context的类型,对应了根、数组、对象三种
几个status(STATUS_OK_AS_IS等),是Context检查后返回给调用者的结果,其中
- STATUS_OK_AS_IS:表明格式检查正确
- STATUS_OK_AFTER_COMMA:表明格式检查正确,且当前位置后面应该添加逗号
- STATUS_OK_AFTER_COLON:表明格式正确,且当前位置后面应该添加冒号
- STATUS_OK_AFTER_SPACE:表明格式正确,且当前位置后面应该添加空白符
- STATUS_EXPECT_VALUE:表明格式错误,预期是一个值
- STATUS_EXPECT_NAME:表明格式错误,预期是一个字段名
对它们的处理方式,在上面展示的方式_verifyValueWrite中也能看到。
其余属性,是为了维持当前状态用,根据状态判定当前调用是否合法,我们忽略”重复属性检查”,主要有两个方法,逻辑见注释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// 写字段名
public int writeFieldName(String name) throws JsonProcessingException {
// 当前类型是对象或已经写过了字段名,肯定就错了嘛
if ((_type != TYPE_OBJECT) || _gotName) {
return STATUS_EXPECT_VALUE;
}
_gotName = true;
_currentName = name;
if (_dups != null) { _checkDup(_dups, name); }
// 如果这是第一个键值对,则啥都不管,否则,需要在这个键前面加逗号
return (_index < 0) ? STATUS_OK_AS_IS : STATUS_OK_AFTER_COMMA;
}
// 写值
public int writeValue() {
if (_type == TYPE_OBJECT) {
// 如果还没有设置键,应该先设置键
if (!_gotName) {
return STATUS_EXPECT_NAME;
}
_gotName = false;
++_index;
// 在值前面加冒号
return STATUS_OK_AFTER_COLON;
}
if (_type == TYPE_ARRAY) {
int ix = _index;
++_index;
// 如果是数组,如果数组里还一个值都没有,啥也不做,否则,应该在这个之前面加逗号
return (ix < 0) ? STATUS_OK_AS_IS : STATUS_OK_AFTER_COMMA;
}
++_index;
// 否则就是根对象咯,如果根对象里还没有元素,就啥也不错,否则应该在前面加空格
return (_index == 0) ? STATUS_OK_AS_IS : STATUS_OK_AFTER_SPACE;
}
编解码器
如果了解Kotlin的序列化设计策略,就很好理解编解码器了,可以将它类比SerialFormat——为了将流式API的复杂逻辑封装起来,提供一个简单易用的API,我们看它的接口定义。
1 | public abstract class ObjectCodec extends TreeCodec implements Versioned |
可以看到,它提供的方法非常有限,大致分为两类
- 针对一般类型及其集合类型的读、写方法,接收JsonParser/JsonGenerator、类型参数
- 针对树模型的读写方法
ObjectMapper
ObjectMapper是data-bind最主要的类,而他,直接继承ObjectCodec,也就是说,可以直接使用ObjectCodec中定义的那些方法。但会发现,最常用的却不是它们,类似SerialFormat,尽管ObjectCodec已经为我们提供了一些方法,但它们只是将流式API的端点和类型联系了起来,还是不够易用,因此data-bind定义了更多易用的方法,将JsonGenerator之类的创建封装在类内部,于是我们就有了writeValueAsString()这样的方法可以直接使用。
来看写方法,其实所有的写方法内部实现都是一样的,writeValueAsString也只是在其内部用一个流缓存序列化输出,再转换为字符串。它们的重点,在于com.fasterxml.jackson.databind.ObjectMapper#_writeValueAndClose
1 | // 所有写方法都会调用的内部方法 |
我们展示了三层调用层次的方法,排除干扰项,要点如下
- 获取待序列化的类型对应的序列化器
- 使用找到的序列化器,执行最后的序列化逻辑
- 向JsonGenerator写入对象起始符
- 向JsonGenerator写入根字段名
- 用得到的序列化器向JsonGenerator写入对象相关信息
- 向JsonGenerator写入对象结束符
这里出现了一个新的概念:序列化器,之前是没有见过的。序列化器和反序列化器的创建,是data-bind的重要内容,我们可以想象一下:序列化器负责对整个对象的序列化,这里的序列化,其实是将对象属性转换为json的token的过程。我们又没有创建序列化器,那要么是编译器创建,要么是运行时创建,很显然前者是不可能的。可以猜测,序列化器的生成,包含了以下逻辑(反序列化类似)
- 包含对Json注解的支持,反射读取注解,然后体现在序列化器上
- 包含对各种功能的支持,目前data-bind的功能主要有MapperFeature、SerializationFeature、DeserializationFeature的支持,生成序列化器时,根据这些条件动态调整转换行为。这其实最终体现在了SerializationConfig中。
- 对新增逻辑的支持,module的扩展性,应该要靠它来支持
有关序列化器的生成逻辑,主要在SerializerProvider,比较深,有兴趣自己跟跟看,解析一下其生成逻辑。对于一个自定义的POJO,创建逻辑最终会来到这里:com.fasterxml.jackson.databind.ser.BeanSerializerFactory#constructBeanOrAddOnSerializer,逻辑过于复杂,所有上面说的那些都交织在其中,有兴趣可以自己研究。下图展示了构建过程中判断MapperFeature.USE_STATIC_TYPING开关的情况。
实际上,序列化器会首先在预先设置的序列化器中查找,然后从缓存中查找,实在没有,才会自动生成,且生成后的序列化器会添加到缓存,避免多次生成,从而提升性能。而那些针对Jackson的三方库,基本都是自定义类型的处理方式,是否可以推测,所谓第三方库,其实就是自己提前定义的序列化器和反序列化器,然后注册进来就好。
正确认识ObjectMapper
ObjectMapper是Jackson使用的核心,一般来说,一个ObjectMapper实例对应了一套配置,配置一旦添加不可去除,如果想用多套不同的配置,则需维护多个ObjectMapper,这也是常规使用方法,你看整个Spring中,正常情况下,都只有一个ObejctMapper实例的。
module
在Jackson主项目下,能看到很多第三方插件(数据类型模块),我们来探究它是如何工作的。
注册一个module的调用方式是
1 | ObjectMapper().registerModule(JavaTimeModule()) |
它干了啥呢?参见com.fasterxml.jackson.databind.ObjectMapper#registerModule(代码太长就不贴了),简单说来
- 将Module依赖的Module进行递归注册
- 将Module中定义的序列化器和反序列化器注册到ObjectMapper
当然,能够自定义的,肯定不止序列化器,看com.fasterxml.jackson.databind.Module.SetupContext
可以看到,它提供了多种配置能力,这些能力怎么用,后面可以单独开一篇文章介绍。
- 可开启各种功能
- 可添加各种序列化器、反序列化器
- 可添加序列化和反序列化描述符
- 添加类型解析器
- 添加注解
- 注册子类型(用于多态)
- 混入
- 命名策略
自定义Module
Jackson提供了SimpleModule,帮助我们在自定义类型module时简化开发,现在我们自定义一个Module。
假设有一系列类型(这里就展示一个),比如
1 | data class Operation2( |
现在希望Jackson支持它们,于是可以定义一个Module,它需要包含针对这些类型的序列化器,将这些序列化器包装进一个自定义Module,如下
1 | class OperationSerializer : StdSerializer<Operation2>(Operation2::class.java) { |
然后,在使用时就可以注册并使用了
1 | fun main() { |
输出如下
1 | {"operationId":1} |
与Kotlin序列化对比
相对而言,Kotlin序列化功能还非常年轻,有类似也有不同,将二者进行对比,有助于理解。
Jackson接口 | Kotlin接口 | 用途 |
---|---|---|
JsonGenerator/JsonParser | Encoder/Decoder | 底层接口,负责将支持的基本类型编码到流中 |
JsonSerializer/JsonDeserializer | KSerializer | 序列化器/反序列化器,负责将具有结构的对象,转换为基本类型 |
ObjectCodec/ObjectMapper | SerialFormat/Json | 编解码器,略有不同,不过都是对用户隐藏了实现,暴露简单接口 |
TreeNode | JsonElement | 树抽象模型,二者都有,不过Jackson的更加强大 |
Module | serializerModule | 功能完全不一样,但思想类似,都是维护一堆上下文对象。 区别在于,前者功能更多;后者只有上下文和多态的声明 |
总结
初以为Jackson,区区一Json序列化库,能复杂到哪里去;看了官方手册,不知道是它写的乱,还是我悟性不好,看半天反正不知道在讲啥;后来耐着性子看,并尝试了不少方式,发现用起来还挺简单,模型上又和Kotlin序列化有几分类似,原理上也没那么难懂嘛;多翻一些源码,发现这个历史悠久的库果然不是泛泛之辈,功能太多了,眼花缭乱的,很多功能单拎出来都能写一篇长文,也不怪得网络上那么多Jackson专栏。所以它也不是那种一两天就能搞定的库,更不是一篇文章能说清楚的。
本文我们简要介绍了Jackson的基础能力、基本原理、基本模型等。抓住Jackson主干是主要目的,细枝末节,更加高级偏僻的功能,留待日后探索。通过本文,你应该知道了
- Jackson由core提供核心能力,data-bind提供各种功能,annotations提供注解支持
- Jackson的常用场景使用方法
- Jackson和核心原理,Module的工作原理
What’s Next
有了本文的基础,我们就能更加流畅地去看其他文章了,官网推荐的那些就很好